Creating a Rich GUI in the IPython Notebook

Sep 15, 2014: Based on this thread.

Dec 19, 2016: Updated for this thread

  • What is the main idea? or…

  • What would you someone else want to embed/reuse?

  • What can you make easy to customize?

  • The controls

  • The main view

  • How far can you push the traitlets system?

  • Things that don’t serialize to JSON might not work very well: arbitrary Python code.

    NOTE: this version has been updated to Jupyter Notebook 4.3 and IPyWidgets 5.2.2 The original version is built for the Notebook in IPython 3.0/master.

Installation

I highly recommend using conda via `miniconda <http://conda.pydata.org/miniconda.html>`__. Once you have conda, you’d run:

[70]:
#!conda install -c conda-forge notebook ipywidgets

If you just did that inside the notebook, you’ll have to restart your notebook server now!

[18]:
from ipywidgets import (
    FlexBox, VBox, HBox, HTML, Box, RadioButtons,
    FloatText, Dropdown, Checkbox, Image, IntSlider, Button,
)
from traitlets import (
    link, Unicode, Float, Int, Enum, Bool,
)




If that just failed, go check `Installation <#Installation>`__ again!

Use `OrderedDict <https://docs.python.org/2/library/collections.html#collections.OrderedDict>`__ for predictable display of key-value pairs.

[19]:
from collections import OrderedDict

CSS helps keep your code concise, as well as make it easier to extend/override.

[20]:
%%html
<style>
/*
    This contents of this would go in a separate CSS file.

    Note the namespacing: this is important for two reasons.
    1) doesn't pollute the global namespace
    2) is _more specific_ than the base styles.
*/

.widget-area .spectroscopy .panel-body{
    padding: 0;
}
.widget-area .spectroscopy .widget-numeric-text{
    width: 5em;
}
.widget-area .spectroscopy .widget-box.start{
    margin-left: 0;
}
.widget-area .spectroscopy .widget-hslider{
    width: 12em;
}

</style>

These few classes wrap up some Bootstrap components: these will be more consistent then coding up your own.

NOTE: Bootstrap will not be available in JupyterLab… you’ll still be able to use it, by requiring it yourself, as from a CDN.
[21]:
class PanelTitle(HTML):
    def __init__(self, *args, **kwargs):
        super(PanelTitle, self).__init__(*args, **kwargs)
        self.on_displayed(self.displayed)
    def displayed(self, _):
        self.add_class("panel-heading panel-title")

class PanelBody(Box):
    def __init__(self, *args, **kwargs):
        super(PanelBody, self).__init__(*args, **kwargs)
        self.on_displayed(self.displayed)
    def displayed(self, _):
        self.add_class("panel-body")

class ControlPanel(Box):
    # A set of related controls, with an optional title, in a box (provided by CSS)
    def __init__(self, title=None, *args, **kwargs):
        super(ControlPanel, self).__init__(*args, **kwargs)

        # add an option title widget
        if title is not None:

            self.children = [
                PanelTitle(value=title),
                PanelBody(children=self.children)
            ]

        self.on_displayed(self.displayed)

    def displayed(self, _):
        self.add_class("panel panel-info")

This notional Spectrogram shows how one might make a widget that redraws based on the state of its data. By defining its external API, including allowed and default values, in the form of linked traitlets, it can be reused without replumbing any events, while a few simple methods like draw make sure it is still easy to use in a programmatic way.

[81]:
import re
from datetime import datetime

class Spectrogram(HTML):
    """
    A notional "complex widget" that knows how to redraw itself when key properties change.
    """
    # Utility
    DONT_DRAW = re.compile(r'^(_.+|value|keys|comm|children|visible|parent|log|config|msg_throttle)$')

    # Lookup tables: this would be a nice place to add i18n, perhaps
    CORRELATION = OrderedDict([(x, x) for x in ["synchronous", "asynchronous", "modulus", "argument"]])
    DRAW_MODE = OrderedDict([(x, x) for x in ["color", "black & white", "contour"]])
    SPECTRUM_SCALE = OrderedDict([(x, x) for x in ["auto", "manual"]])
    SPECTRUM_DIRECTIONS = OrderedDict([(x, x) for x in ["left", "right", "bottom", "top"]])

    # pass-through traitlets
    correlation = Enum(CORRELATION.values(), default_value=list(CORRELATION.values())[0], sync=True)
    draw_mode = Enum(DRAW_MODE.values(), default_value=list(DRAW_MODE.values())[0], sync=True)

    spectrum_direction_left = Float(1000, sync=True)
    spectrum_direction_right = Float(1000, sync=True)
    spectrum_direction_bottom = Float(1000, sync=True)
    spectrum_direction_top = Float(1000, sync=True)

    spectrum_contours = Int(4, sync=True)
    spectrum_zmax = Float(0.0566468618, sync=True)
    spectrum_scale = Enum(SPECTRUM_SCALE, default_value=list(SPECTRUM_SCALE.values())[0], sync=True)

    axis_x = Float(50, sync=True)
    axis_y = Float(50, sync=True)
    axis_display = Bool(True, sync=True)

    def __init__(self, *args, **kwargs):
        """
        Creates a spectrogram
        """
        super(Spectrogram, self).__init__(*args, **kwargs)
        # self.on_trait_change(lambda name, old, new: self.draw(name, old, new))
        self.observe(self.draw)
        self.on_displayed(self.displayed)

    def displayed(self, _):
        self.add_class("col-xs-9")
        self.draw()

    def draw(self, change=None):
        change = change or {}
        name = change.get("name")
        old = change.get("old")
        new = change.get("new")

        if name is not None and self.DONT_DRAW.match(name):
            return

        value = "<h2>Imagine a picture here, drawn with...</h2>"

        if name is None:
            value += '<div class="alert alert-info">redraw forced at %s!</div>' % (
                datetime.now().isoformat(' ')
            )

        value += "\n".join([
            '<p><span class="label label-%s">%s</span> %s</p>' % (
                'success' if traitlet == name else 'default',
                traitlet,
                getattr(self, traitlet)
            )
            for traitlet in sorted(self.trait_names())
            if not self.DONT_DRAW.match(traitlet)
        ])
        self.value = value

The actual GUI. Note that the individual components of the view are responsible for: - creating widgets - linking to the graph widget

[82]:
class Spectroscopy(Box):
    """
    An example GUI for a spectroscopy application.

    Note that `self.graph` is the owner of all of the "real" data, while this
    class handles creating all of the GUI controls and links. This ensures
    that the Graph itself remains embeddable and rem
    """
    def __init__(self, graph=None, graph_config=None, *args, **kwargs):
        self.graph = graph or Spectrogram(**(graph_config or {}))
        # Create a GUI
        kwargs["orientation"] = 'horizontal'
        kwargs["children"] = [
            self._controls(),
            VBox(children=[
                self._actions(),
                self.graph
            ])
        ]
        super(Spectroscopy, self).__init__(*args, **kwargs)

        self.on_displayed(self.displayed)

    def displayed(self, _):
        # namespace and top-level bootstrap
        self.add_class("spectroscopy row")

    def _actions(self):
        redraw = Button(description="Redraw")
        redraw.on_click(lambda x: self.graph.draw())
        return HBox(children=[redraw])

    def _controls(self):
        panels = VBox(children=[
            HBox(children=[
                self._correlation(),
                self._draw_mode(),
            ]),
            self._spectrum(),
            self._axes()
        ])
        panels.on_displayed(lambda x: panels.add_class("col-xs-3"))
        return panels

    def _correlation(self):
        # create correlation controls. NOTE: should only be called once.
        radios = RadioButtons(options=self.graph.CORRELATION)
        link((self.graph, "correlation"), (radios, "value"))
        return ControlPanel(title="correlation", children=[radios])

    def _draw_mode(self):
        # create draw mode controls.  NOTE: should only be called once.
        radios = RadioButtons(options=self.graph.DRAW_MODE)
        link((self.graph, "draw_mode"), (radios, "value"))
        return ControlPanel(title="draw", children=[radios])

    def _spectrum(self):
        # create spectrum controls.  NOTE: should only be called once.
        directions = []

        for label in self.graph.SPECTRUM_DIRECTIONS:
            direction = FloatText(description=label, value=1000.0)
            link((self.graph, "spectrum_direction_" + label), (direction, "value"))
            directions.append(direction)

        direction_rows = [HBox(children=directions[x::2]) for x in range(2)]

        contour = IntSlider(description="contours", min=1)
        link((self.graph, "spectrum_contours"), (contour, "value"))

        zmax = FloatText(description="z-max", width="100%")
        link((self.graph, "spectrum_zmax"), (zmax, "value"))

        scale = RadioButtons(description="scale", options=self.graph.SPECTRUM_SCALE)
        link((self.graph, "spectrum_scale"), (scale, "value"))

        return ControlPanel(title="spectrum",
            children=direction_rows + [
                contour,
                zmax,
                scale
            ]
        )

    def _axes(self):
        # create spectrum controls.  NOTE: should only be called once.
        axis_x = FloatText(description="X div.")
        link((self.graph, "axis_x"), (axis_x, "value"))

        axis_y = FloatText(description="Y div.")
        link((self.graph, "axis_y"), (axis_y, "value"))

        axes = HBox(children=[axis_x, axis_y])

        axis_display = Checkbox(description="display")
        link((self.graph, "axis_display"), (axis_display, "value"))

        return ControlPanel(title="axes",
            children=[
                axis_display,
                axes
            ]
        )

Hooray, everything is defined, now we can try this out!

[83]:
spectrogram = Spectrogram()
spectrogram

Its traits can be updated directly, causing immediate update:

[84]:
spectrogram.axis_display = False

The graph can be passed directly to the interactive GUI, sharing the same data between the two views.

[85]:
gui = Spectroscopy(graph=spectrogram)
gui
[ ]: